Skip to content

fix: avoid user package hydration mismatch#2981

Open
liangmiQwQ wants to merge 11 commits into
npmx-dev:mainfrom
liangmiQwQ:liang/codex/fix-1948-prehydrate
Open

fix: avoid user package hydration mismatch#2981
liangmiQwQ wants to merge 11 commits into
npmx-dev:mainfrom
liangmiQwQ:liang/codex/fix-1948-prehydrate

Conversation

@liangmiQwQ

@liangmiQwQ liangmiQwQ commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Close #1948, Close #2753

The /~username page rendered package search results on the server with the default search provider, while the client could hydrate with a different provider from localStorage. That could produce hydration mismatches and a visible provider swap for users who selected npm.

According to #1948 (comment), I end up choosing to use Algolia in server for SSR & SEO, and use cilent side's config to fetch the real data.

image

🤖 Generated with Codex

@vercel

vercel Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Jul 4, 2026 6:13am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Jul 4, 2026 6:13am
npmx-lunaria Ignored Ignored Jul 4, 2026 6:13am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR addresses hydration mismatches caused by client-only settings by mount-gating the search provider read in useSettings, adding a data-user-packages-username attribute and a broadened loading-spinner condition to the username page, and introducing a client-side prefetch cache in useUserPackages seeded during onPrehydrate and reused on initial npm load.

Changes

Search provider hydration fix

Layer / File(s) Summary
Mount-gated search provider setting
app/composables/useSettings.ts
useSearchProvider now uses useMounted() to return DEFAULT_SETTINGS.searchProvider before mount and the stored value afterwards; the setter is unchanged.
Username page prehydrate context
app/pages/~[username]/index.vue
Adds a bound data-user-packages-username attribute to <main> and broadens the LoadingSpinner condition to show on idle or pending status when there are no packages and no error.
npm prefetch seeding and reuse
app/composables/npm/useUserPackages.ts
Introduces a global window.__NPMX_USER_PACKAGES_PREFETCH__ cache keyed by username, an onPrehydrate hook that seeds it from localStorage settings and route context via a cached npm registry fetch, and initial-load logic that consumes the prefetched response when it still matches the current user/provider, bypassing the normal request.

Sequence Diagram(s)

sequenceDiagram
  participant UsernamePage
  participant useUserPackages
  participant PrefetchCache as window.__NPMX_USER_PACKAGES_PREFETCH__
  participant npmRegistry

  UsernamePage->>useUserPackages: onPrehydrate with username context
  useUserPackages->>npmRegistry: fetch npm search results (force-cache)
  npmRegistry-->>useUserPackages: prefetched search response
  useUserPackages->>PrefetchCache: store response by username

  UsernamePage->>useUserPackages: initial npm load
  useUserPackages->>PrefetchCache: read and consume prefetched response
  PrefetchCache-->>useUserPackages: matching response
  useUserPackages-->>UsernamePage: prefetched packages
Loading
sequenceDiagram
  participant Browser
  participant useSearchProvider
  participant settings

  Browser->>useSearchProvider: read searchProvider during SSR
  useSearchProvider-->>Browser: DEFAULT_SETTINGS.searchProvider
  Browser->>useSearchProvider: component mounted
  useSearchProvider->>settings: read settings.value.searchProvider
  settings-->>useSearchProvider: stored searchProvider
  useSearchProvider-->>Browser: stored searchProvider
Loading

Possibly related issues

Suggested reviewers: ghostdevv, 43081j

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes align with #1948 and #2753 by avoiding server reads of localStorage settings and prehydrating npm-registry data on the client.
Out of Scope Changes check ✅ Passed The mount gating, prehydrate cache, and page attribute changes are all directly tied to the hydration fix.
Title check ✅ Passed The title clearly matches the main change: fixing a user package hydration mismatch.
Description check ✅ Passed The description accurately explains the hydration mismatch and the move to client-side settings-driven fetching.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 50.00000% with 1 line in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
app/composables/useSettings.ts 50.00% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
app/composables/npm/useUserPackages.ts (1)

113-113: 🚀 Performance & Scalability | 🔵 Trivial

Correct fix, but note the SSR/SEO trade-off.

Disabling server fetch here fully resolves the hydration mismatch, but it also means the ~[username] package list is no longer present in the server-rendered HTML — crawlers and no-JS clients will now see only a loading state until client-side fetch completes. This is consistent with the PR's stated approach, but as the linked issue notes, a longer-term fix of moving settings like searchProvider to cookies would let this stay SSR-rendered while still avoiding the mismatch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/composables/npm/useUserPackages.ts` at line 113, The hydration mismatch
fix in useUserPackages is to disable the server-side fetch for the package
search state by keeping the default emptySearchResponse fallback and setting the
request to client-only. Update the useUserPackages composable so the search data
for ~[username] is fetched only on the client, which prevents SSR from rendering
a stale package list against client state. Keep this change localized to the
package list fetch/config in useUserPackages, and note that it intentionally
trades SSR content for hydration stability.
app/composables/useSettings.ts (1)

199-212: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace the manual mount flag with useMounted()

useMounted() is a drop-in replacement for the current shallowRef + onMounted pair and keeps the composable a bit cleaner.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/composables/useSettings.ts` around lines 199 - 212, `useSearchProvider`
currently uses a manual `shallowRef` plus `onMounted` to track mount state.
Replace that pattern with `useMounted()` in the same composable so
`searchProvider` continues to read `DEFAULT_SETTINGS.searchProvider` before
mount and `settings.value.searchProvider` after mount, while keeping the
existing computed getter/setter behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@app/composables/npm/useUserPackages.ts`:
- Line 113: The hydration mismatch fix in useUserPackages is to disable the
server-side fetch for the package search state by keeping the default
emptySearchResponse fallback and setting the request to client-only. Update the
useUserPackages composable so the search data for ~[username] is fetched only on
the client, which prevents SSR from rendering a stale package list against
client state. Keep this change localized to the package list fetch/config in
useUserPackages, and note that it intentionally trades SSR content for hydration
stability.

In `@app/composables/useSettings.ts`:
- Around line 199-212: `useSearchProvider` currently uses a manual `shallowRef`
plus `onMounted` to track mount state. Replace that pattern with `useMounted()`
in the same composable so `searchProvider` continues to read
`DEFAULT_SETTINGS.searchProvider` before mount and
`settings.value.searchProvider` after mount, while keeping the existing computed
getter/setter behavior unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a9124468-b91c-45e1-976b-5b4d2758f874

📥 Commits

Reviewing files that changed from the base of the PR and between 1880374 and 0bba87f.

📒 Files selected for processing (4)
  • app/composables/npm/useUserPackages.ts
  • app/composables/useSettings.ts
  • app/pages/~[username]/index.vue
  • test/e2e/hydration.spec.ts

@gameroman gameroman left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you address the Coderabbit's Nitpick comments please

@liangmiQwQ

Copy link
Copy Markdown
Contributor Author

I am dealing with it actively now :)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
app/composables/useSettings.ts (1)

198-207: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Good SSR-safe hydration fix; consider a brief comment.

Swapping the manual onMounted/shallowRef pattern for VueUse's useMounted() is correct and SSR-safe (it internally guards with getCurrentInstance() before registering the lifecycle hook), so the initial client render matches SSR output and avoids the hydration mismatch. Since the rationale isn't obvious from the code alone, a short comment would help future readers understand why mount-gating is needed here.

📝 Suggested comment
   const { settings } = useSettings()
+  // Fall back to the default value until mounted to avoid SSR/client hydration
+  // mismatches, since `settings` is sourced from localStorage (client-only).
   const isMounted = useMounted()

As per coding guidelines, "Add comments only to explain complex logic or non-obvious implementations."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/composables/useSettings.ts` around lines 198 - 207, The SSR-safe mount
gating in useSearchProvider is correct, but the intent is not obvious from the
computed getter that switches between settings.value.searchProvider and
DEFAULT_SETTINGS.searchProvider based on useMounted(). Add a short inline
comment near the mount check in useSearchProvider explaining that the mounted
guard keeps the initial SSR/client render aligned and avoids hydration mismatch,
so future readers understand why the value is deferred until mount.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@app/composables/useSettings.ts`:
- Around line 198-207: The SSR-safe mount gating in useSearchProvider is
correct, but the intent is not obvious from the computed getter that switches
between settings.value.searchProvider and DEFAULT_SETTINGS.searchProvider based
on useMounted(). Add a short inline comment near the mount check in
useSearchProvider explaining that the mounted guard keeps the initial SSR/client
render aligned and avoids hydration mismatch, so future readers understand why
the value is deferred until mount.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 86bf9d05-a996-4cd2-b8a7-8ee7923a9ad8

📥 Commits

Reviewing files that changed from the base of the PR and between 0bba87f and 2f8b665.

📒 Files selected for processing (1)
  • app/composables/useSettings.ts

@gameroman gameroman left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good 👍

Lets see what other maintainers think

Specifically the Coderabbit's comment about SEO

@gameroman gameroman added the needs review This PR is waiting for a review from a maintainer label Jul 1, 2026
@gameroman gameroman requested a review from a team July 1, 2026 14:04

@ghostdevv ghostdevv left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 with Coderabbit - I think the way this type of issue has been solved in other ares is using use prehydrate

@liangmiQwQ liangmiQwQ marked this pull request as draft July 4, 2026 03:09
@liangmiQwQ

Copy link
Copy Markdown
Contributor Author

I will improve it soon.

@liangmiQwQ liangmiQwQ marked this pull request as ready for review July 4, 2026 05:10

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
app/composables/npm/useUserPackages.ts (1)

12-14: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate, inconsistent typings for the same window global.

UserPackagesPrefetchWindow (lines 12-14) types the prefetch map as Record<string, Promise<RawNpmSearchResponse | null> | undefined>, while the inline type in onPrehydrate (lines 77-79) types the same global as Record<string, Promise<unknown> | undefined>. Since both are casts against the same runtime object, having two separate shapes risks silent drift if one is updated without the other. Consider extracting a single shared type/interface for __NPMX_USER_PACKAGES_PREFETCH__ and reusing it in both places.

♻️ Proposed consolidation
-type UserPackagesPrefetchWindow = Window & {
-  __NPMX_USER_PACKAGES_PREFETCH__?: Record<string, Promise<RawNpmSearchResponse | null> | undefined>
-}
+type NpmUserPackagesPrefetchMap = Record<string, Promise<RawNpmSearchResponse | null> | undefined>
+type UserPackagesPrefetchWindow = Window & {
+  __NPMX_USER_PACKAGES_PREFETCH__?: NpmUserPackagesPrefetchMap
+}
-    const prefetchWindow = window as typeof window & {
-      __NPMX_USER_PACKAGES_PREFETCH__?: Record<string, Promise<unknown> | undefined>
-    }
+    const prefetchWindow = window as UserPackagesPrefetchWindow
     const prefetches = (prefetchWindow.__NPMX_USER_PACKAGES_PREFETCH__ ||= {})

Also applies to: 77-79

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/composables/npm/useUserPackages.ts` around lines 12 - 14, There are two
conflicting type definitions for the same window global used by useUserPackages
and onPrehydrate, which can drift over time. Extract a single shared
type/interface for __NPMX_USER_PACKAGES_PREFETCH__ and reuse it in both the
UserPackagesPrefetchWindow declaration and the cast inside onPrehydrate. Keep
the promise value type consistent everywhere so both references describe the
same runtime object shape.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@app/composables/npm/useUserPackages.ts`:
- Around line 12-14: There are two conflicting type definitions for the same
window global used by useUserPackages and onPrehydrate, which can drift over
time. Extract a single shared type/interface for __NPMX_USER_PACKAGES_PREFETCH__
and reuse it in both the UserPackagesPrefetchWindow declaration and the cast
inside onPrehydrate. Keep the promise value type consistent everywhere so both
references describe the same runtime object shape.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ec58aee6-9844-4152-8294-0f4bc083e35a

📥 Commits

Reviewing files that changed from the base of the PR and between 2f8b665 and 21ba140.

📒 Files selected for processing (2)
  • app/composables/npm/useUserPackages.ts
  • app/pages/~[username]/index.vue

@liangmiQwQ liangmiQwQ marked this pull request as draft July 4, 2026 05:38
@liangmiQwQ liangmiQwQ marked this pull request as ready for review July 4, 2026 05:46
@liangmiQwQ

liangmiQwQ commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

The linting CI falls, and it seems by network problem...

@liangmiQwQ liangmiQwQ requested a review from ghostdevv July 4, 2026 05:54
@shuuji3

shuuji3 commented Jul 4, 2026

Copy link
Copy Markdown
Member

I just rerun the ci action and it passed. To trigger another Vercel deployment, please push new commit (I don't have permission).

@liangmiQwQ

Copy link
Copy Markdown
Contributor Author

@shuuji3 Thank you, I will do it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs review This PR is waiting for a review from a maintainer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

hydration error on org pages address settings related hydration issues using prehydrate

4 participants